#!/usr/bin/env tclsh
# ======================================================================
#
# File: csvcat.tcl
#
# About:
#   Displays the columns from a CSV file in columnar format
#
# Author:
#   Hai Vu (G mail ID: haivu2004)
#
# History:
# 2010-02-24 version 2.4
# - Fixed bug: -x 9-$ where $ is not recognized
#
# 2010-01-04 version 2.3
# - no filename means stdin
# - Changed: the -d option now works like the old -h t omit header
# - Changed: the -h option now shows help
#
# 2009-10-05 version 2.2
# - Add the -r flag for displaying in record mode
#
# 2009-10-02 version 2.1
# - A dash in place of file name: use stdin
# - Fixed bug: blank lines before the header could cause erratic output
#
# 2009-10-01 version 2.0
# - Command line now takes options (flags)
# - Add flags to include/exclude column names
# - Add a flag to omit the display of header
# - Add a flag to omit the display of blank lines
# - No longer crash because of misspelled column name
#
# 2009-09-25 version 1.1
# - csvcat will assume the .csv extension if not supplied to omit the
# display of header
#
# Who-knows-when version 1.0
# - First working version
#
# Enhancement Ideas:
# - Ability to sort columns
#
# ======================================================================

package require csv
package require Tclx
package require cmdline

# ======================================================================
#
# proc: convertNumbersToNames
#   Takes a list that consists names and/or numbers and convert all the
#   numbers to names. The names are taken from the second list.
#
# Parameters:
#   numberList - a list of column names and/or numbers. Each list item
#   can be a column name, a single number, a range of number (e.g.
#   1-10), or a dollar sign ($) to indicate the last column.
#   nameList - a list of column names to look up from
#
# Example:
# (start code)
# % convertNumbersToNames {1-3 5} {c1 c2 c3 c4 c5 c6 c7 c8 c9 c10}
# c1 c2 c3 c5
# % convertNumbersToNames {1-3 c5} {c1 c2 c3 c4 c5 c6 c7 c8 c9 c10}
# c1 c2 c3 c5
# % convertNumbersToNames {-1,-2} {c1 c2 c3 c4 c5 c6 c7 c8 c9 c10}
# c10 c9
# (end code)
#
# ======================================================================

proc convertNumbersToNames {numberList nameList} {
    set newNameList {}
    set nameListLength [llength $nameList]
    foreach item $numberList {
        set range {(\d+)\-(\$|\d+)}
        if {[regexp $range $item all startNumber endNumber]} {
            # Case: a range of number, e.g: 1-5
            if {$endNumber == "$"} {set endNumber $nameListLength}
            incr startNumber -1
            incr endNumber -1
            set newNameList [concat \
                $newNameList        \
                [lrange $nameList $startNumber $endNumber]]

        } elseif {[string is integer -strict $item]} {
            # Case: a single number, positive or negative
            if {$item > 0} {
                # 1 = first item, 2 = second item, ...
                incr item -1
            } elseif {$item < 0} {
                # -1 = last item, -2 = next to last, ...
                set item [expr {$nameListLength + $item}]
            } else {
                puts stderr "Column number must be greater than zero"
                exit 1
            }
            lappend newNameList [lindex $nameList $item]

        } elseif {$item == "$"} {
            # Case: dollar sign = last column
            lappend newNameList [lindex $nameList end]

        } elseif {[lsearch -exact $nameList $item] != -1} {
            # Case: a valid name
            lappend newNameList $item

        } else {
            # Case: invalid name
            puts stderr "Invalid column name: $item"
            exit 1
        }
    }

    return $newNameList
}

proc showUsageAndExit {banner usage {msg ""}} {
	puts stderr "
$banner

Usage:
$usage

$msg"

	exit 1
}

# ======================================================================
#
# Main
#
# ======================================================================

# Initialize variables
set version 2.4
set banner "[cmdline::getArgv0] $version - \
Displays a csv file in columnar format
by Hai Vu (G mail ID: haivu2004)"
set flags {
    {i.arg "all" "List of columns to display, separated by commas"}
    {x.arg ""    "List of columns to exclude, separated by commas"}
    {r           "Record view"}
    {b           "Omit blank lines"}
    {d           "Data only, no header"}
	{h           "Help"}
}
set usage [cmdline::usage $flags "\[ options \] \[ csv_file \]"]

# Parse command line and verify the parameters
if {[catch {array set opt [cmdline::getoptions argv $flags $usage]}]} {
	showUsageAndExit $banner $usage
}

# Show usage and exit upon user's request
if {$opt(h)} {
	showUsageAndExit $banner $usage
}

# Give the command line options descriptive names (columnNames vs.
# opt(i))
set columnNames [csv::split $opt(i)]
set excludedNames [csv::split $opt(x)]
set recordView $opt(r)
set omitHeader $opt(d)
set omitBlankLine $opt(b)

# Process the remaining command line argument. At this point, we only
# expect a single argument and assume to be the file name.
set argc [llength $argv]
if {$argc == 1} {
    # Case: 1 argument, assume to be file name
    set fileName [lindex $argv 0]

    if {$fileName == "-"} {
        # Case: a dash means to use stdin
        set fileHandle stdin
    } else {
        # Case: a file name, which might or might not exist

        # Append the .csv extension only when a) the extension is not
        # there, and b) the resulting file exists
        if {![file exists $fileName] &&
            [file extension $fileName] == "" &&
            [file exists ${fileName}.csv] } {
            append fileName .csv
        }

        # Check for file existence
        if {![file exists $fileName]} {
			showUsageAndExit $banner $usage "$fileName does not exist"
        }

        # Open the file for reading the csv contents
        set fileHandle [open $fileName "r"]
		if {!$omitHeader} {
	        puts "\nFile: $fileName\n"
		}
    }
} elseif {$argc == 0} {
	# Case: no more arguments means to use stdin
	set fileHandle stdin
} else {
	showUsageAndExit $banner $usage "Please supply only one file"
}

set headers {}
set outputLines {}
while {[gets $fileHandle line] != -1} {
    if {[regexp {^\s*#.*$} $line]} {
        # Ignore commented line
    } elseif {[regexp {^\s*$} $line]} {
        # Process blank lines: either ignore, or save in the output
        # lines for later printing
        if {!$omitBlankLine && $headers != ""} {lappend outputLines ""}
    } elseif {$headers == ""} {
        # Retrieve the column names from the first non-blank line
        set headers [csv::split $line]

        # if the user wants all columns, do it
        if {$columnNames == "all" || $columnNames == ""} {
            set columnNames $headers
        }

        # Convert column numbers to column names
        set columnNames [convertNumbersToNames $columnNames $headers]
        set excludedNames \
            [convertNumbersToNames $excludedNames $headers]

        # Exclude columns
        set temp {}
        foreach name $columnNames {
            if {[lsearch -exact $excludedNames $name] == -1} {
                lappend temp $name
            }
        }
        set columnNames $temp

        # Determine the width of each column and the width of the widest
        # column, maxColumnWidth, used when display in record view
        set maxColumnWidth 0
        foreach column $columnNames {
            set columnWidth($column) [string length $column]
            if {$columnWidth($column) > $maxColumnWidth} {
                set maxColumnWidth $columnWidth($column)
            }
        }
        incr maxColumnWidth
    } else {
        # A regular line: split and create a keyed list for easy
        # retrieval. Also note the largest width to accomodate each
        # column.
        set newLine {}
        foreach header $headers cell [csv::split $line] {
            set width [string length $cell]
            if {[lsearch -exact $columnNames $header] != -1} {
                if {$width > $columnWidth($header)} {
                    set columnWidth($header) $width
                }
                keylset newLine $header $cell
            }
        }
        lappend outputLines $newLine
    }
}
close $fileHandle

# Display the header, if applicable
if {!$omitHeader && !$recordView} {
    foreach column $columnNames {
        puts -nonewline [format "%-*s " $columnWidth($column) $column]
    }
    puts ""

    foreach column $columnNames {
        puts -nonewline "[string repeat - $columnWidth($column)] "
    }
    puts ""
}

# Display the contents
foreach line $outputLines {
    if {[llength $line] == 0} {
        puts ""
    } else {
        foreach column $columnNames {
            if {[catch {set cell [keylget line $column]}]} {
                set cell "-"
            }
            if {$recordView} {
                puts [format "%-*s %s" $maxColumnWidth $column: $cell]
            } else {
                puts -nonewline \
                    [format "%-*s " $columnWidth($column) $cell]
            }
        }
        puts ""
    }
}

